Skip to content

Implement schema composition across multiple kinds (plan 156) - #288

Merged
jeduden merged 6 commits into
mainfrom
claude/kind-schema-composition-HYhfg
May 16, 2026
Merged

Implement schema composition across multiple kinds (plan 156)#288
jeduden merged 6 commits into
mainfrom
claude/kind-schema-composition-HYhfg

Conversation

@jeduden

@jeduden jeduden commented May 14, 2026

Copy link
Copy Markdown
Owner

Summary

Implements plan 156: when a file resolves to multiple kinds that each declare a required-structure schema, the schemas now compose instead of the last one winning. The merge layer accumulates schema sources into a schema-sources list, and the rule loads and composes them at check time.

Key Changes

Core composition logic (internal/schema/compose.go, new):

  • Compose() merges multiple schemas by unioning frontmatter keys (with CUE conjunction & for shared keys), merging sections by literal heading text, OR-ing the closed: flag, and picking the first non-empty require.filename pattern
  • Sections with identical headings combine recursively; wildcard slots and preambles remain distinct
  • Comprehensive test coverage in compose_test.go validates all composition rules

Rule refactoring (internal/rules/requiredstructure/rule.go):

  • New SchemaSource struct holds either a file path or pre-parsed inline schema
  • Rule.Sources field (canonical) replaces single-source logic; Schema and InlineSchema now mirror the first source for backward compatibility
  • ApplySettings() refactored into focused helpers (applySchemaSetting, applyInlineSchemaSetting, applySchemaSourcesSetting)
  • New parseSchemaSources() parses the schema-sources list installed by the merge layer
  • reflectSingleSource() keeps legacy fields in sync when exactly one source is configured
  • SettingMergeMode() returns MergeAppend for schema-sources so layers accumulate

Merge layer (internal/config/merge.go):

  • New translateSchemaSource() converts legacy schema: and inline-schema: settings into single-entry schema-sources lists
  • effectiveRules() applies translation to all layers (defaults, convention, user, kinds, overrides)
  • Removed clearSchemaState() and related "last source wins" logic; sources now accumulate
  • SettingMergeMode() updated to append schema-sources across layers

Check-time composition (internal/rules/requiredstructure/rule.go):

  • Check() loads all sources and calls schema.Compose() to merge them before validation
  • Handles both file and inline sources; file sources read through lint.File.RootFS
  • Maintains backward compatibility: single-source configs work unchanged

Config tests (internal/config/schema_kinds_test.go):

  • Updated TestEffectiveInjectsInlineSchema to verify schema-sources list structure
  • Renamed TestEffectiveClearsPriorSchemaWhenNewSourceArrives to TestEffectiveComposesSchemaSourcesAcrossKinds; now verifies both sources accumulate instead of the last winning

Rule tests (internal/rules/requiredstructure/compose_test.go, new):

Documentation and examples:

  • Updated docs/guides/schemas.md with composition rules and worked example (directive-rule-readme + rule-readme)
  • Updated docs/development/architecture/cross-system.md with schema composition contract
  • Simplified internal/rules/directive-proto.md to declare only Pattern-specific constraints; common rule-README structure now comes from proto.md via composition
  • Moved Meta-Information sections in four directive READMEs (MDS019, MDS021, MDS038, MDS039) to appear before Pattern (composition order)
  • Updated .mdsmith.yml to assign all four directive READMEs to both rule-readme and directive-rule-readme kinds

Notable Implementation Details

  • Inline schemas are pre

https://claude.ai/code/session_01C4XwUp4AkhzqjrvSSHMMZS

Copilot AI review requested due to automatic review settings May 14, 2026 12:45
@codecov

codecov Bot commented May 14, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 96.37%. Comparing base (3785d0d) to head (5141bc6).

Additional details and impacted files
Components Coverage Δ
Go 96.34% <100.00%> (+0.10%) ⬆️
TypeScript 99.35% <ø> (ø)

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Implements plan 156: when a file resolves to multiple kinds that each declare a required-structure schema, the schemas now compose rather than the last layer winning. The merge layer accumulates each source into a schema-sources list, and the rule loads and composes them at check time via a new internal/schema Compose API. Documentation, the four directive READMEs, and directive-proto.md are updated to exploit composition (directive-rule-readme now declares only the Pattern requirement; rule-readme contributes the rest).

Changes:

  • New internal/schema/Compose that unions frontmatter (CUE & for shared keys), merges sections by literal heading text, OR-s closed:, and errors on conflicting require.filename / index.output.
  • Refactored requiredstructure.Rule around an ordered Sources []SchemaSource; merge layer translates legacy schema: / inline-schema: into appended schema-sources entries; check composes file + inline sources at runtime, retaining single-source legacy paths for body/heading sync.
  • Reassigned the four directive READMEs to both rule-readme and directive-rule-readme, simplified directive-proto.md, and moved the Meta-Information sections before Pattern in MDS019/021/038/039.

Reviewed changes

Copilot reviewed 20 out of 20 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
plan/156_kind-schema-composition.md Marks plan tasks/criteria done and rewrites them to describe what shipped.
PLAN.md Flips plan 156 status to ✅.
internal/schema/compose.go New Compose function and helpers (sections/frontmatter/index/acronyms/cross-refs).
internal/schema/compose_test.go Unit tests covering composition rules and acceptance criteria.
internal/rules/requiredstructure/rule.go Adds Sources/SchemaSource, multi-source Check, legacy single-source compatibility.
internal/rules/requiredstructure/compose_test.go End-to-end Check tests for composed multi-kind sources.
internal/config/merge.go New translateSchemaSource flow; removes clearSchemaState/"last source wins".
internal/config/provenance.go Mirrors the schema-source translation in provenance/explain layers.
internal/config/schema_kinds_test.go Updated to assert schema-sources accumulation rather than clearing.
internal/config/kinds_test.go Provenance assertions updated for translated schema-sources entries.
internal/engine/kinds_test.go Asserts kind-level schema: reaches the rule via schema-sources.
internal/rules/directive-proto.md Trimmed to declare only Pattern + nature: directive.
internal/rules/MDS019/021/038/039 READMEs Meta-Information moved above Pattern to match composed section order.
internal/rules/MDS020-required-structure/README.md Notes multi-kind composition and links to schemas guide.
docs/guides/schemas.md New “Composition across kinds” section with worked example.
docs/development/architecture/cross-system.md New schema-composition contract block.
.mdsmith.yml Reassigns directive READMEs to both kinds; removes the prior workaround.

Comment thread internal/rules/requiredstructure/rule.go Outdated
jeduden pushed a commit that referenced this pull request May 14, 2026
Copilot review caught that Fix only read r.InlineSchema and silently
no-op'd for multi-source configs (where reflectSingleSource leaves
InlineSchema nil). checkComposedSources still validated the composed
schema's Index block, so users would see a "missing index" diagnostic
that Fix couldn't resolve.

Fix now composes the same way Check does (via the new
composedSchemaForFix helper) and writes the index when the composed
schema declares one. Added a regression test that exercises a
two-source config where only one source carries the index block.
@jeduden
jeduden requested a review from Copilot May 14, 2026 12:53

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 21 out of 21 changed files in this pull request and generated no new comments.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 21 out of 21 changed files in this pull request and generated no new comments.

Copilot AI review requested due to automatic review settings May 14, 2026 15:45

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 22 out of 22 changed files in this pull request and generated 2 comments.

Comment thread internal/schema/compose.go
Comment thread internal/schema/compose.go
jeduden pushed a commit that referenced this pull request May 14, 2026
Copilot review on PR #288 surfaced two real composition bugs:

1. compose.go silently picked RootLevel from the first non-nil
   input. Mixing an inline schema (RootLevel=2, H1 owned by the
   title) with a file-based proto.md that wraps its sections in
   an H1 wildcard (RootLevel=1) made the validator's section
   walk start at the wrong depth for one input. Now Compose
   errors when inputs disagree on the effective root level so
   the misconfiguration surfaces as a config error instead of
   silently mis-validated headings.

2. composeAcronyms unioned Scope across inputs, but Acronyms.
   Scope semantics are "empty = document-wide; non-empty =
   restricted". Unioning silently narrowed a document-wide
   check to the other input's restricted list. Now once any
   input declares Acronyms with no Scope restriction the
   composed Scope becomes nil (document-wide) — restricted
   inputs only union with each other.

Updated TestComposedSchemaForFix_FileSource to use a proto.md
that roots at H2 so the file source agrees with the inline
default of 2; added compose_test cases for the new RootLevel
mismatch error and for the document-wide-wins acronym path
(both orders + both restricted).

Also extended the CI debug capture (added in 685b228) to post
the mdsmith fix diff as a PR comment, so the contents of the
stale catalog are visible without authenticated access to the
workflow logs.
Copilot AI review requested due to automatic review settings May 14, 2026 19:16
@github-actions

Copy link
Copy Markdown

CI debug: mdsmith fix diff on this commit

diff --git a/.claude/skills/markdown-audit/SKILL.md b/.claude/skills/markdown-audit/SKILL.md
index 5d201a7..cb90bec 100644
--- a/.claude/skills/markdown-audit/SKILL.md
+++ b/.claude/skills/markdown-audit/SKILL.md
@@ -129,12 +129,10 @@ where: 'nature: "directive"'
 sort: id
 row: "- `internal/rules/{id}-{name}/pattern/` ({name})"
 ?>
-
 - `internal/rules/MDS019-catalog/pattern/` (catalog)
 - `internal/rules/MDS021-include/pattern/` (include)
 - `internal/rules/MDS038-toc/pattern/` (toc)
 - `internal/rules/MDS039-build/pattern/` (build)
-
 <?/catalog?>
 
 Do not paraphrase directive syntax from memory.
diff --git a/PLAN.md b/PLAN.md
index 35d22c3..128b475 100644
--- a/PLAN.md
+++ b/PLAN.md
@@ -85,6 +85,6 @@ footer: |
 | 154 | ✅     | sonnet | [arch-fix: extract cross-rule helpers](plan/154_arch-fix-rule-helper-extraction.md)                                       |
 | 155 | ✅     | sonnet | [arch-fix: relocate convention types out of markdownflavor](plan/155_arch-fix-convention-config-ownership.md)             |
 | 156 | ✅     | opus   | [Composable required-structure schemas across multiple kinds](plan/156_kind-schema-composition.md)                        |
-| 157 | 🔳     | sonnet | [Catalog filter by front matter property](plan/157_catalog-where-filter.md)                                               |
 | 156 | 🔲     | opus   | [Section schema — unify entry shape under `heading:` discriminator](plan/156_schema-entry-unification.md)                 |
+| 157 | 🔳     | sonnet | [Catalog filter by front matter property](plan/157_catalog-where-filter.md)                                               |
 <?/catalog?>
diff --git a/editors/claude-code-audit/skills/markdown-audit/SKILL.md b/editors/claude-code-audit/skills/markdown-audit/SKILL.md
index 35636e6..e0013eb 100644
--- a/editors/claude-code-audit/skills/markdown-audit/SKILL.md
+++ b/editors/claude-code-audit/skills/markdown-audit/SKILL.md
@@ -134,12 +134,10 @@ where: 'nature: "directive"'
 sort: id
 row: "- `internal/rules/{id}-{name}/pattern/` ({name})"
 ?>
-
 - `internal/rules/MDS019-catalog/pattern/` (catalog)
 - `internal/rules/MDS021-include/pattern/` (include)
 - `internal/rules/MDS038-toc/pattern/` (toc)
 - `internal/rules/MDS039-build/pattern/` (build)
-
 <?/catalog?>
 
 Do not paraphrase directive syntax from memory.
diff --git a/internal/rules/index.md b/internal/rules/index.md
index 47d1dc6..883f631 100644
--- a/internal/rules/index.md
+++ b/internal/rules/index.md
@@ -91,60 +91,10 @@ header: |
   |------|------|-------------|
 row: "| [{id}]({filename}) | `{name}` | {description} |"
 ?>
-| Rule                                                          | Name                                 | Description                                                                                                                   |
-|---------------------------------------------------------------|--------------------------------------|-------------------------------------------------------------------------------------------------------------------------------|
-| [MDS001](MDS001-line-length/README.md)                        | `line-length`                        | Line exceeds maximum length.                                                                                                  |
-| [MDS002](MDS002-heading-style/README.md)                      | `heading-style`                      | Heading style must be consistent.                                                                                             |
-| [MDS003](MDS003-heading-increment/README.md)                  | `heading-increment`                  | Heading levels should increment by one. No jumping from `#` to `###`.                                                         |
-| [MDS004](MDS004-first-line-heading/README.md)                 | `first-line-heading`                 | First line of the file should be a heading.                                                                                   |
-| [MDS005](MDS005-no-duplicate-headings/README.md)              | `no-duplicate-headings`              | No two headings should have the same text.                                                                                    |
-| [MDS006](MDS006-no-trailing-spaces/README.md)                 | `no-trailing-spaces`                 | No trailing whitespace at the end of lines.                                                                                   |
-| [MDS007](MDS007-no-hard-tabs/README.md)                       | `no-hard-tabs`                       | No tab characters. Use spaces instead.                                                                                        |
-| [MDS008](MDS008-no-multiple-blanks/README.md)                 | `no-multiple-blanks`                 | No more than one consecutive blank line.                                                                                      |
-| [MDS009](MDS009-single-trailing-newline/README.md)            | `single-trailing-newline`            | File must end with exactly one newline character.                                                                             |
-| [MDS010](MDS010-fenced-code-style/README.md)                  | `fenced-code-style`                  | Fenced code blocks must use a consistent delimiter.                                                                           |
-| [MDS011](MDS011-fenced-code-language/README.md)               | `fenced-code-language`               | Fenced code blocks must specify a language.                                                                                   |
-| [MDS012](MDS012-no-bare-urls/README.md)                       | `no-bare-urls`                       | URLs must be wrapped in angle brackets or as a link, not left bare.                                                           |
-| [MDS013](MDS013-blank-line-around-headings/README.md)         | `blank-line-around-headings`         | Headings must have a blank line before and after.                                                                             |
-| [MDS014](MDS014-blank-line-around-lists/README.md)            | `blank-line-around-lists`            | Lists must have a blank line before and after.                                                                                |
-| [MDS015](MDS015-blank-line-around-fenced-code/README.md)      | `blank-line-around-fenced-code`      | Fenced code blocks must have a blank line before and after.                                                                   |
-| [MDS016](MDS016-list-indent/README.md)                        | `list-indent`                        | List items must use consistent indentation.                                                                                   |
-| [MDS017](MDS017-no-trailing-punctuation-in-heading/README.md) | `no-trailing-punctuation-in-heading` | Headings should not end with punctuation.                                                                                     |
-| [MDS018](MDS018-no-emphasis-as-heading/README.md)             | `no-emphasis-as-heading`             | Don't use bold or emphasis on a standalone line as a heading substitute.                                                      |
-| [MDS019](MDS019-catalog/README.md)                            | `catalog`                            | Catalog content must reflect selected front matter fields from files matching its glob.                                       |
-| [MDS020](MDS020-required-structure/README.md)                 | `required-structure`                 | Document structure and front matter must match its schema.                                                                    |
-| [MDS021](MDS021-include/README.md)                            | `include`                            | Include section content must match the referenced file.                                                                       |
-| [MDS022](MDS022-max-file-length/README.md)                    | `max-file-length`                    | File must not exceed maximum number of lines.                                                                                 |
-| [MDS023](MDS023-paragraph-readability/README.md)              | `paragraph-readability`              | Paragraph readability index must not exceed a threshold.                                                                      |
-| [MDS024](MDS024-paragraph-structure/README.md)                | `paragraph-structure`                | Paragraphs must not exceed sentence and word limits.                                                                          |
-| [MDS025](MDS025-table-format/README.md)                       | `table-format`                       | Tables must have consistent column widths and padding.                                                                        |
-| [MDS026](MDS026-table-readability/README.md)                  | `table-readability`                  | Tables must stay within readability complexity limits.                                                                        |
-| [MDS027](MDS027-cross-file-reference-integrity/README.md)     | `cross-file-reference-integrity`     | Links to local files and heading anchors must resolve.                                                                        |
-| [MDS028](MDS028-token-budget/README.md)                       | `token-budget`                       | File must not exceed a token budget.                                                                                          |
-| [MDS029](MDS029-conciseness-scoring/README.md)                | `conciseness-scoring`                | Paragraph conciseness score must not fall below a threshold.                                                                  |
-| [MDS030](MDS030-empty-section-body/README.md)                 | `empty-section-body`                 | Section headings must include meaningful body content.                                                                        |
-| [MDS031](MDS031-unclosed-code-block/README.md)                | `unclosed-code-block`                | Fenced code blocks must have a closing fence delimiter.                                                                       |
-| [MDS032](MDS032-no-empty-alt-text/README.md)                  | `no-empty-alt-text`                  | Images must have non-empty alt text for accessibility.                                                                        |
-| [MDS033](MDS033-directory-structure/README.md)                | `directory-structure`                | Markdown files must exist only in explicitly allowed directories.                                                             |
-| [MDS034](MDS034-markdown-flavor/README.md)                    | `markdown-flavor`                    | Flags Markdown syntax that the declared target flavor does not render.                                                        |
-| [MDS035](MDS035-toc-directive/README.md)                      | `toc-directive`                      | Flag renderer-specific TOC directives that render as literal text on CommonMark and goldmark.                                 |
-| [MDS036](MDS036-max-section-length/README.md)                 | `max-section-length`                 | Section length must not exceed per-level or per-heading limits.                                                               |
-| [MDS037](MDS037-duplicated-content/README.md)                 | `duplicated-content`                 | Paragraphs should not repeat verbatim across Markdown files.                                                                  |
-| [MDS038](MDS038-toc/README.md)                                | `toc`                                | Keep toc generated heading lists in sync with document headings.                                                              |
-| [MDS039](MDS039-build/README.md)                              | `build`                              | Validate `<?build?>` directive parameters and keep the section body in sync with the recipe's rendered `body-template`.       |
-| [MDS040](MDS040-recipe-safety/README.md)                      | `recipe-safety`                      | Validate each build.recipes command for shell-safety at lint time; the rule never executes any binary.                        |
-| [MDS041](MDS041-no-inline-html/README.md)                     | `no-inline-html`                     | Raw HTML tags in Markdown are not allowed; use a Markdown construct or an mdsmith directive instead.                          |
-| [MDS042](MDS042-emphasis-style/README.md)                     | `emphasis-style`                     | Enforces a single delimiter character for bold and italic emphasis, and optionally forbids cross-delimiter nesting.           |
-| [MDS043](MDS043-no-reference-style/README.md)                 | `no-reference-style`                 | Reference-style links and footnotes require global definition resolution; flag them in favor of inline links.                 |
-| [MDS044](MDS044-horizontal-rule-style/README.md)              | `horizontal-rule-style`              | Thematic breaks must use a consistent delimiter style, exact length, and blank-line spacing.                                  |
-| [MDS045](MDS045-list-marker-style/README.md)                  | `list-marker-style`                  | Unordered list items must use the configured bullet marker character.                                                         |
-| [MDS046](MDS046-ordered-list-numbering/README.md)             | `ordered-list-numbering`             | Ordered list items must be numbered in the configured style.                                                                  |
-| [MDS047](MDS047-ambiguous-emphasis/README.md)                 | `ambiguous-emphasis`                 | Forbid emphasis sequences whose meaning a human cannot predict at a glance.                                                   |
-| [MDS048](MDS048-git-hook-sync/README.md)                      | `git-hook-sync`                      | Git artifacts must match the canonical glob-based template derived from .mdsmith.yml.                                         |
-| [MDS049](MDS049-no-space-in-link-text/README.md)              | `no-space-in-link-text`              | Link text and image alt text must not have leading or trailing whitespace inside the brackets.                                |
-| [MDS050](MDS050-proper-names/README.md)                       | `proper-names`                       | Configured proper names (e.g. JavaScript, GitHub) must appear with their canonical casing.                                    |
-| [MDS051](MDS051-single-h1/README.md)                          | `single-h1`                          | At most one H1 heading is allowed per file.                                                                                   |
-| [MDS052](MDS052-no-space-in-code-spans/README.md)             | `no-space-in-code-spans`             | Inline code spans with leading or trailing whitespace inside the backticks are almost always typos; flag them.                |
-| [MDS053](MDS053-no-unused-link-definitions/README.md)         | `no-unused-link-definitions`         | Every `[label]: url` definition must be consumed by at least one reference-style link or image; duplicate labels are flagged. |
-| [MDS054](MDS054-no-undefined-reference-labels/README.md)      | `no-undefined-reference-labels`      | Reference-style links and images must have a matching link reference definition in the same file.                             |
+| Rule                               | Name      | Description                                                                                                             |
+|------------------------------------|-----------|-------------------------------------------------------------------------------------------------------------------------|
+| [MDS019](MDS019-catalog/README.md) | `catalog` | Catalog content must reflect selected front matter fields from files matching its glob.                                 |
+| [MDS021](MDS021-include/README.md) | `include` | Include section content must match the referenced file.                                                                 |
+| [MDS038](MDS038-toc/README.md)     | `toc`     | Keep toc generated heading lists in sync with document headings.                                                        |
+| [MDS039](MDS039-build/README.md)   | `build`   | Validate `<?build?>` directive parameters and keep the section body in sync with the recipe's rendered `body-template`. |
 <?/catalog?>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 22 out of 22 changed files in this pull request and generated 1 comment.

Comment thread .github/workflows/ci.yml Outdated
jeduden pushed a commit that referenced this pull request May 14, 2026
Copilot review caught that Fix only read r.InlineSchema and silently
no-op'd for multi-source configs (where reflectSingleSource leaves
InlineSchema nil). checkComposedSources still validated the composed
schema's Index block, so users would see a "missing index" diagnostic
that Fix couldn't resolve.

Fix now composes the same way Check does (via the new
composedSchemaForFix helper) and writes the index when the composed
schema declares one. Added a regression test that exercises a
two-source config where only one source carries the index block.
jeduden pushed a commit that referenced this pull request May 14, 2026
Copilot review on PR #288 surfaced two real composition bugs:

1. compose.go silently picked RootLevel from the first non-nil
   input. Mixing an inline schema (RootLevel=2, H1 owned by the
   title) with a file-based proto.md that wraps its sections in
   an H1 wildcard (RootLevel=1) made the validator's section
   walk start at the wrong depth for one input. Now Compose
   errors when inputs disagree on the effective root level so
   the misconfiguration surfaces as a config error instead of
   silently mis-validated headings.

2. composeAcronyms unioned Scope across inputs, but Acronyms.
   Scope semantics are "empty = document-wide; non-empty =
   restricted". Unioning silently narrowed a document-wide
   check to the other input's restricted list. Now once any
   input declares Acronyms with no Scope restriction the
   composed Scope becomes nil (document-wide) — restricted
   inputs only union with each other.

Updated TestComposedSchemaForFix_FileSource to use a proto.md
that roots at H2 so the file source agrees with the inline
default of 2; added compose_test cases for the new RootLevel
mismatch error and for the document-wide-wins acronym path
(both orders + both restricted).

Also extended the CI debug capture (added in 685b228) to post
the mdsmith fix diff as a PR comment, so the contents of the
stale catalog are visible without authenticated access to the
workflow logs.
jeduden pushed a commit that referenced this pull request May 14, 2026
The mdsmith check failed in CI because the PR's merge ref
included main's stale `nature:directive` catalog body in
internal/rules/index.md plus the post-rebase blank-line drift
in the two markdown-audit SKILL.md files. Run `mdsmith fix .`
on the rebased tree to regenerate every catalog so the merged
content stays in sync.

Also reverts the temporary mdsmith CI debug instrumentation
added in 685b228 / 121e7e0 (the PR-comment fix-diff capture
that flagged the stale catalogs). The Copilot review on PR
#288 correctly noted the wider permissions and the mutate-and-
post pattern were unrelated to plan 156, so back to the
original `./mdsmith check .` invocation.
@jeduden
jeduden force-pushed the claude/kind-schema-composition-HYhfg branch from 121e7e0 to 28abcaa Compare May 14, 2026 19:22
Comment thread internal/config/merge.go Outdated
![demo](demo.gif)
<?/build?>
```

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

metainfo should be added at the end of a rule readme.
ensure the kind's schema enforces this. metainfo should be rendered by injecting the variables from the frontmatter.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tracked as plan 169 (plan/169_rule-readme-meta-information-sync.md, pushed in 5141bc6).

Scoping this out of #288: enforcing Meta-Information as terminal means dropping the trailing ## ... wildcard in internal/rules/proto.md and revalidating all ~54 rule READMEs, and the frontmatter {field} body-sync is — per docs/guides/schemas.md — explicitly an unwired follow-up on MDS020's legacy file-schema parser. Plan 169 captures both halves with acceptance tests so #288 stays focused on plan 156 composition. Leaving this thread open until 169 lands.


Generated by Claude Code

Copilot AI review requested due to automatic review settings May 15, 2026 10:01

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 25 out of 25 changed files in this pull request and generated no new comments.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 15 out of 15 changed files in this pull request and generated no new comments.

jeduden pushed a commit that referenced this pull request May 16, 2026
Copilot review (PR #288): when a single config layer sets both a
non-empty `schema:` and a non-empty `inline-schema:`,
extractSchemaSourceFromSettings returned at the `schema` arm and
TranslateLayerSettings then stripped both keys, silently dropping
the inline source. The rule's rejectDualSchemaSettings guard in
ApplySettings was bypassed because translation removed the keys
before ApplySettings ran, and top-level cfg.Rules / overrides /
convention presets are not covered by validateKindSchemaSources.

TranslateLayerSettings now detects a dual-source layer
(hasDualSchemaSource, mirroring rejectDualSchemaSettings'
non-empty semantics) and passes the layer through untouched, so
the keys survive deep-merge and the existing guard still surfaces
the original "cannot set both" config error. Cross-layer
composition is unaffected — the check only fires when one map
carries both. Added regression tests; new code is 100% line and
branch covered.
@jeduden
jeduden requested a review from Copilot May 16, 2026 11:30

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 16 out of 16 changed files in this pull request and generated no new comments.

claude added 3 commits May 16, 2026 11:48
Adds the composition engine so a file resolved by multiple kinds
can get the union of every kind's required-structure schema
instead of the last one winning.

- internal/schema.Compose merges frontmatter (CUE conjunction for
  shared keys), sections (merge by heading label; `## ...` slots,
  bare `?`, preamble stay distinct), Closed (stricter wins),
  Matcher cardinality (required-by-any wins), Filename (first
  non-empty; conflicts error), CrossReferences/Acronyms/Index
  (acronyms: document-wide scope wins). Built against the
  plan-156 #295 unified `heading:` discriminator model.
- The config merge layer accumulates each layer's `schema:` /
  `inline-schema:` into an append-mode `schema-sources` list via
  the new rule.SettingsTranslator interface, so internal/config
  carries no rule-name special case (mirrors rule.ListMerger).
- MDS020 loads every source, composes them, validates the
  composed schema; single-source keeps the legacy file/inline
  paths; multi-source Fix writes the composed Index side-output.
- docs: schemas guide gains a "Composition across kinds" worked
  example; cross-system doc records the contract.

Deferred: wiring directive-rule-readme to compose on top of
rule-readme. main now keeps Meta-Information last with Pattern
before it (#295/#302); appending directive-proto.md's Pattern
after rule-readme's Meta-Information would order it wrong. That
schema restructuring (review comment 2) is tracked separately;
.mdsmith.yml and directive-proto.md stay on main's standalone
directive-rule-readme schema for now.

Full suite, lint, and mdsmith check green.
codecov/changes flagged 8 uncovered lines in internal/schema/
compose.go — the schema-model port's mergeMatcher and cloneContent
helpers. Add targeted same-package unit tests for: a/b nil matcher
arms, min/max widening (required-wins, wider-max, optional-both,
bounded+unbounded), Sequential OR, and the Columns deep-copy
branch. compose.go is now 100% line and branch covered (gobco
clean); full suite, lint, and mdsmith check green.
Copilot review (PR #288): when a single config layer sets both a
non-empty `schema:` and a non-empty `inline-schema:`,
extractSchemaSourceFromSettings returned at the `schema` arm and
TranslateLayerSettings then stripped both keys, silently dropping
the inline source. The rule's rejectDualSchemaSettings guard in
ApplySettings was bypassed because translation removed the keys
before ApplySettings ran, and top-level cfg.Rules / overrides /
convention presets are not covered by validateKindSchemaSources.

TranslateLayerSettings now detects a dual-source layer
(hasDualSchemaSource, mirroring rejectDualSchemaSettings'
non-empty semantics) and passes the layer through untouched, so
the keys survive deep-merge and the existing guard still surfaces
the original "cannot set both" config error. Cross-layer
composition is unaffected — the check only fires when one map
carries both. Added regression tests; new code is 100% line and
branch covered.
@jeduden
jeduden force-pushed the claude/kind-schema-composition-HYhfg branch from 69421a0 to ba407fc Compare May 16, 2026 11:50
@jeduden
jeduden requested a review from Copilot May 16, 2026 11:50

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 16 out of 16 changed files in this pull request and generated 2 comments.

Comment thread internal/rules/requiredstructure/compose_test.go Outdated
Comment thread internal/schema/compose.go
isLikelyArchetypeName, extractSchemaSourceFromSettings, and
EffectiveKinds were only exercised indirectly via call sites,
leaving codecov/changes flagging per-file coverage drift. Add
table-driven unit tests that hit every return path directly so
the three functions report 100% line and branch coverage.

https://claude.ai/code/session_01C4XwUp4AkhzqjrvSSHMMZS

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 17 out of 17 changed files in this pull request and generated no new comments.

mergeMatcher widened the run-length max (took the larger of the two
maxima), which broke the composition contract "every input's
constraint holds": composing 1..3 with 5..10 silently yielded 5..10,
dropping the ..3 cap. Make cardinality a true intersection — min is
the larger bound, max is the smaller (0 = unbounded), and disjoint
ranges return a composition error, mirroring how conflicting filename
patterns surface. Thread the error through mergeScopes and
composeSectionLists; flatten the latter behind a section accumulator
so the added error paths stay within the complexity budget.

Also relocate the misplaced TestApplySettings_SchemaSourcesList doc
comment to its function so it no longer stacks above an unrelated
test.

https://claude.ai/code/session_01C4XwUp4AkhzqjrvSSHMMZS

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 17 out of 17 changed files in this pull request and generated no new comments.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 17 out of 17 changed files in this pull request and generated no new comments.

PR #288 review raised that Meta-Information must be the terminal
section and its bullets should render from frontmatter. The
rule-readme schema permits later sections and MDS020's file-schema
path still uses the legacy parser, so frontmatter body-sync is
unwired. Scope that out of the plan-156 PR into its own plan rather
than expanding #288's blast radius across all rule READMEs.

https://claude.ai/code/session_01C4XwUp4AkhzqjrvSSHMMZS

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 18 out of 18 changed files in this pull request and generated no new comments.

@jeduden
jeduden merged commit f5b1bc4 into main May 16, 2026
23 checks passed
jeduden pushed a commit that referenced this pull request May 19, 2026
…ssue refs

Lines like "  #22 \"Mandatory headings\"" and "  #288.**" are GitHub
issue/PR references soft-wrapped inside list items, not malformed ATX
headings. Computing `after` before the MD023 check and returning nil
when after[0] is a digit avoids flagging these lines.

Add TestCheck_IssueReference, TestCheck_IssueReferenceAtColumn1, and
TestFix_IssueReferenceUnchanged to pin the behaviour.

https://claude.ai/code/session_01KCANC7X1jkYcLdAUqmBeaz
jeduden pushed a commit that referenced this pull request May 19, 2026
The previous guard returned nil for any '#'+digit line regardless of
indentation. That silenced MD018 for genuine malformed headings like
#1Heading or ##22Title at column 1.

Scope the guard to leading>0: a '#'+digit run on an indented line is
almost certainly a soft-wrapped issue/PR reference (#22, #288) — at
column 1 it is a missing-space defect and is flagged normally.

Add TestCheck_DigitAtColumn1IsFlagged and TestFix_DigitAtColumn1 to
pin the column-1 behaviour and prevent regression.

https://claude.ai/code/session_01KCANC7X1jkYcLdAUqmBeaz
jeduden added a commit that referenced this pull request May 19, 2026
* feat: add MDS059 atx-heading-whitespace rule

Implements rule MDS059 covering the markdownlint ATX-heading
whitespace family (MD018–MD021) and heading indentation (MD023).

Detects missing space, multiple spaces, closed ATX markers, and
leading indentation on raw ATX heading lines. Autofix normalizes
every defect to open ATX with a single space and no leading indent.
Skips fenced/indented code blocks and directive bodies.

Closes plan 176.

https://claude.ai/code/session_01KCANC7X1jkYcLdAUqmBeaz

* test(MDS059): add coverage tests for internal helpers

Three statement-coverage gaps in rule.go — the all-hash guard in
checkClosingATX, the level-out-of-range guard in normalizeLine, and
the empty-string early return in extractContent — are only reachable
by calling the unexported functions directly.  Add
rule_coverage_test.go to hit all three paths and reach 100% statement
coverage.

https://claude.ai/code/session_01KCANC7X1jkYcLdAUqmBeaz

* fix(MDS059): address Copilot review — CRLF, tab, C# false positive

- Add \r to TrimRight calls so CRLF line endings never produce
  spurious "missing space after # in heading" diagnostics on
  empty ATX headings (##\r\n).

- Flag a tab immediately after opening hashes as "missing space"
  and normalise it to a single space (#\tHeading → # Heading).

- Only treat a trailing # run as a closing ATX marker when
  preceded by whitespace (CommonMark rule). Previously, content
  like "# C#" was wrongly diagnosed and mangled by Fix. The
  case with no preceding space now returns nil cleanly.
  Consequence: #Heading# is fixed only for MD018 (missing
  opening space) → # Heading#; MD020 coverage downgraded to
  partial in the coverage matrix.

- Update rule README to reflect accurate behaviour.

https://claude.ai/code/session_01KCANC7X1jkYcLdAUqmBeaz

* fix(MDS059): address second Copilot review

- Coverage matrix: correct preamble to 41 of 52 (2 partial),
  11 remaining; remove completed plan 176 from plan list.

- normalizeLine: preserve trailing \r when the original line has
  one so CRLF files don't get mixed LF/CRLF after a partial fix.
  Test: TestFix_PreservesCRLFOnRewrittenLines.

- Add TestCheck_SkipsPIBlock and TestFix_PreservesPIBlock to pin
  the directive-body (PI block) skip behaviour for Check and Fix.

https://claude.ai/code/session_01KCANC7X1jkYcLdAUqmBeaz

* fix(test): drain interleaved notifications in lspPipe.shutdown

shutdown() used request(), which reads exactly one frame. After a
rename the server emits publishDiagnostics notifications that can
arrive before the shutdown response, causing the id==99 assertion
to see nil. Switch to requestPickResult(), which already loops past
interleaved server frames, matching the pattern used by every other
multi-step LSP test helper.

https://claude.ai/code/session_01KCANC7X1jkYcLdAUqmBeaz

* docs(plan 176): clarify CommonMark interpretation for #Heading# case

Split the acceptance criterion that covered both `# Heading #` and
`#Heading#` into separate bullets. The `#Heading#` bullet now explicitly
notes that per CommonMark a trailing `#` without preceding whitespace is
content, not a closing marker, so MD020 is partial for that case. This
matches the implementation and resolves the Copilot review comment about
the checked-off criterion contradicting the fix output.

https://claude.ai/code/session_01KCANC7X1jkYcLdAUqmBeaz

* fix(MDS064): rename message to "multiple spaces or tabs after # in heading"

leadingSpaces() counts tabs as well as spaces, so the check at line 71
fires for mixed whitespace like "# \tHeading" (space then tab). The old
message "multiple spaces after # in heading" was inaccurate in that case.
Rename to "multiple spaces or tabs after # in heading" and add tests for
the space+tab pattern.

https://claude.ai/code/session_01KCANC7X1jkYcLdAUqmBeaz

* fix(MDS064): rename package headingwhitespace → atxheadingwhitespace; register in all.go

The package name headingwhitespace did not follow the repo convention
of deriving the directory name from the rule name with hyphens removed
(blockquote-whitespace → blockquotewhitespace, list-marker-space →
listmarkerspace). Rename to atxheadingwhitespace for consistency.

Also fixes a production bug: the package was never blank-imported in
internal/rules/all/all.go, so MDS064 was not registered and would
never run in cmd/mdsmith. Add the import in alphabetical order.

Also updates plan/176 front matter and design text to replace the
provisional MDS060 ID with the shipped MDS064.

https://claude.ai/code/session_01KCANC7X1jkYcLdAUqmBeaz

* fix(MDS064): skip '#' followed by digit to avoid false positives on issue refs

Lines like "  #22 \"Mandatory headings\"" and "  #288.**" are GitHub
issue/PR references soft-wrapped inside list items, not malformed ATX
headings. Computing `after` before the MD023 check and returning nil
when after[0] is a digit avoids flagging these lines.

Add TestCheck_IssueReference, TestCheck_IssueReferenceAtColumn1, and
TestFix_IssueReferenceUnchanged to pin the behaviour.

https://claude.ai/code/session_01KCANC7X1jkYcLdAUqmBeaz

* fix(lint): sort atxheadingwhitespace import into alphabetical position

The import was left at the position where headingwhitespace used to sit
(after headingstyle) instead of its correct alphabetical slot after
ambiguousemphasis. Moves it so gofmt/goimports is satisfied.

https://claude.ai/code/session_01KCANC7X1jkYcLdAUqmBeaz

* docs(MDS064): clarify space requirement only applies when heading has content

The README said the opening hashes must be followed by "exactly one space"
without qualification, implying empty headings like "##" are invalid. Reword
to make clear the space check only applies when the heading has content;
empty headings are valid and produce no diagnostic.

https://claude.ai/code/session_01KCANC7X1jkYcLdAUqmBeaz

* fix(MDS064): narrow digit guard to indented lines only

The previous guard returned nil for any '#'+digit line regardless of
indentation. That silenced MD018 for genuine malformed headings like
#1Heading or ##22Title at column 1.

Scope the guard to leading>0: a '#'+digit run on an indented line is
almost certainly a soft-wrapped issue/PR reference (#22, #288) — at
column 1 it is a missing-space defect and is flagged normally.

Add TestCheck_DigitAtColumn1IsFlagged and TestFix_DigitAtColumn1 to
pin the column-1 behaviour and prevent regression.

https://claude.ai/code/session_01KCANC7X1jkYcLdAUqmBeaz

---------

Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants